Skip to content

feat(mysql): table DDL and generated migrations, with a migration fuzzer (#53) - #136

Merged
Makisuo merged 7 commits into
mainfrom
feat/mysql-schema
Nov 6, 2026
Merged

Makisuo merged 7 commits into
mainfrom
feat/mysql-schema

Conversation

@Makisuo

@Makisuo Makisuo commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #135, so review and merge that one first. This gives MySQL and MariaDB table DDL and generated migrations, the same way SQLite has them, plus a live migration fuzzer.

What changed

  • MySQL.table can declare DDL. MySQL.table(name, { columns, primaryKey, indexes, tenantColumn, casing }) now carries its DDL, using the same overloads as SQLite.table. The value is still a query Table, with ddl and problems beside it.
    • MySQL.column, MySQL.index and MySQL.uniqueIndex go with it, and { external: true, columns } declares a table another tool owns.
    • Literal defaults on TEXT, BLOB and JSON are written DEFAULT ('…').
    • Problems are recorded for:
      • TEXT, BLOB or JSON in a key or index
      • a nullable primary key column
      • a key over 3072 bytes
      • a name over 64 characters
      • index names that collide
  • /schema adds the MySQL migration pieces: mysqlEntitiesOf, renderMysqlSchema, diffMysqlSchemas, MysqlMigrationOp, renderMysqlOp, renderMysqlMigrationSql and MysqlSnapshot.
    • The diff alters tables in place with ADD, DROP, MODIFY and CHANGE COLUMN, primary key and index changes, and RENAME TABLE.
    • Drops and type changes need confirm_data_loss.
    • NOT NULL over existing NULLs fills them with the default first.
  • effect-orm generate with dialect: "mysql" writes the drizzle-kit 1.0 folder layout, one statement per breakpoint. As with SQLite, effect-orm does not apply these migrations yet: /migrate, verify --schema and --from-drizzle refuse MySQL clearly.
  • Fuzzer: tests/fuzz/migrations.mysql.fuzz.test.ts generates random before/after schemas: nine column types, nullability, defaults, keys, unique and indexed columns, and table and column renames, including case-only ones. For each it:
    • seeds rows into before
    • applies the generated migration
    • requires information_schema to match a fresh create of after, with the seeded values of kept columns intact
  • CI: the MySQL/MariaDB job and nightly soak from feat(mysql): MySQL and MariaDB query dialect, fuzzed on both (#53) #135 run the new suites. The MySQL tests in src/ and tests/fuzz/, which includes the new migration fuzzer, are picked up by the existing filters.

What the fuzzer found

  • MariaDB keeps a stale JSON check name across a rename. It gives every JSON column an automatic CHECK (json_valid(...)) named after the column, and RENAME COLUMN leaves the old name in place, so a migrated database differed from a fresh create. Renames are now written as CHANGE COLUMN. Pinned in regressions.ts.

How it was verified

  • Migration fuzzer soak: FUZZ_SCALE=20 FUZZ_VERBOSE=1 checked 600 of 600 inputs on MySQL 8.4 and 600 of 600 on MariaDB 11.4, with nothing skipped. In a 300-input sample every input produced a migration, 132 ran on seeded rows, and every op kind appeared.
  • Live DDL tests (tests/ddl.mysql.test.ts) apply the rendered DDL on both servers and read every default back through the builder. A live generate test checks that migrations applied in order match a fresh create.
  • Unit, type and generate tests: src/mysql/ddl.test.ts (24 tests), src/kit/mysql-generate.test.ts, and the extended src/mysql/mysql.test-d.ts.
  • Locally, without databases: bun run build, bun run typecheck and bun run test pass (1,576 passed; the live suites skip without servers), along with the export catalog (216 /mysql exports), doc citations and 40 typechecked doc examples.
  • Not run locally on the final commit: the full suite with every server live. My local Docker stopped partway through, so this PR's CI is the live check on the final commit.

Not in this PR

  • --from-drizzle for MySQL
  • a MySQL catalog reader (verify, drift)
  • AUTO_INCREMENT, foreign keys, CHECK constraints, prefix and expression indexes, and column collations
  • AFTER placement for added columns

A new NOT NULL column without a default fills existing rows with the type's implicit default. This is documented, not refused.

Part of #53.

🤖 Generated with Claude Code


Autofix with [code]smith
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.

Makisuo and others added 5 commits November 6, 2026 02:10
MySQL.table(name, { columns, primaryKey, indexes, tenantColumn, casing })
returns a MysqlSchemaTable, as SQLite.table does, with column, index and
uniqueIndex. /schema gains mysqlEntitiesOf, renderMysqlSchema,
diffMysqlSchemas, renderMysqlOp and the MySQL entities and snapshot.

Tables are CREATE TABLE IF NOT EXISTS ... ENGINE=InnoDB DEFAULT
CHARSET=utf8mb4 with the primary key and UNIQUE constraints inside, and
separate CREATE INDEX statements. A literal default on TEXT, BLOB or JSON
is written DEFAULT ('...'). Definitions record problems for TEXT, BLOB and
JSON in keys, nullable key columns, keys past 3072 bytes, names past 64
characters and colliding index names.

The diff alters in place: ADD / DROP / MODIFY COLUMN (filling NULLs with
the default before NOT NULL), DROP / ADD PRIMARY KEY around the column
changes, index and UNIQUE drops before them and adds after, RENAME TABLE,
and CHANGE COLUMN for a rename: MariaDB keeps a JSON column's
json_valid CHECK under the old name through RENAME COLUMN.

The /migrate runner, verify and verifySnapshot refuse a MySQL folder or
snapshot, as they refuse SQLite's: no ledger or catalog reader yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
generate diffs MySQL.table definitions against the folder and writes
<timestamp>_<name>/migration.sql plus a mysql snapshot.json, one
statement per --> statement-breakpoint, as for SQLite. verify --schema
refuses a MySQL config before building its driver, and --from-drizzle
refuses dialect "mysql". A drizzle-kit snapshot naming mysql is read as
MySQL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Random before/after schemas (types, nullability, defaults, keys, UNIQUE
columns, indexes, table and column renames) are created in throwaway
databases on MySQL 8.4 and MariaDB 11.4. The migration runs on seeded
rows; information_schema must match a fresh create of the new schema,
and the kept columns their seeded values. Pins the MariaDB RENAME COLUMN
input that found the json_valid CHECK name drift.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Explain Change →

Caution

Review failed

The pull request is closed.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@maple-review-bot

This comment has been minimized.

…ClickHouse --from-drizzle message

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@maple-review-bot

This comment has been minimized.

@maple-review-bot

maple-review-bot Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Maple review

🟡 Confidence 6/10 · needs attention. Held at 6 because a correctness warning is open.
1 warning −1 · tests covered · risk high −1

MySQL gets table DDL, an in-place schema diff and generate migrations, with a live fuzzer. One diff path writes an ALTER that aborts on existing NULLs; otherwise the change is sound. The open src/schema.ts errors are Schema module-loading frames in other services, untouched here.

  • MySQL.table carries DDL with column, index and uniqueIndex helpers plus problem detection
  • diffMysqlSchemas alters tables in place, gating drops and type changes on confirm_data_loss
  • generate with dialect: "mysql" writes migration.sql; migrate, verify and --from-drizzle refuse MySQL
  • A live fuzzer compares information_schema after migration with a fresh create

Before merge

  • Manual · Run the live MySQL 8.4 and MariaDB 11.4 suites on the final commit (EFFECT_ORM_MYSQL_URL, EFFECT_ORM_MARIADB_URL)

Findings

🟠 Warning · F3 · fill with no default writes MODIFY … NOT NULL that fails on existing NULLs

correctness · src/schema/mysql-ops.ts:75-81

Making an existing nullable column NOT NULL with no default leaves op.fill true while column.default is null, so only MODIFY COLUMN … NOT NULL is written. Strict mode refuses it over any NULL row (the comment above says so), and MySQL DDL is not transactional, so the statements before it in migration.sql stay applied.

In `diffMysqlSchemas`, when `fill` is needed and `column.default === null`, push an `unsupported` entry instead of the op, so `generate` refuses the change rather than writing a statement that aborts on rows.
🤖 Prompt to fix this finding with an AI agent
Findings from an automated review of commit d1d37adf5ead4ff846d16bcb72ee8be93796f515. Verify each one against the current code before changing anything, fix only those that still apply, and keep each fix to the lines it names.

---

F3 · Warning · correctness · src/schema/mysql-ops.ts:75-81
`fill` with no default writes `MODIFY … NOT NULL` that fails on existing NULLs
Making an existing nullable column NOT NULL with no default leaves `op.fill` true while `column.default` is null, so only `MODIFY COLUMN … NOT NULL` is written. Strict mode refuses it over any NULL row (the comment above says so), and MySQL DDL is not transactional, so the statements before it in `migration.sql` stay applied.
Suggested fix: In `diffMysqlSchemas`, when `fill` is needed and `column.default === null`, push an `unsupported` entry instead of the op, so `generate` refuses the change rather than writing a statement that aborts on rows.

Production impact

Open errors in the changed files (8)
Issue Service Occurrences File
ToolCallFailed maple-security-audit 838 src/schema.ts
@maple/http/errors/WarehouseAuthError maple-web 20 src/schema.ts
ContextBudgetError maple-security-audit 8 src/schema.ts
ModelProtocolError (2 issues) maple-chat 11 src/schema.ts
AgentPolicyError maple-chat 7 src/schema.ts
@maple/security-audit/AgentError maple-security-audit 4 src/schema.ts
Error maple-web 4 src/schema.ts
ContextBudgetError maple-chat 3 src/schema.ts

After this merges, Maple checks whether they stop.

d1d37ad · What was checked, coverage and telemetry details are on the Maple / review check. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

@maple-review-bot maple-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 inline note from Maple's review. The score and summary are in the review comment above.

Comment thread src/schema/mysql-ops.ts
const modify = (to: MysqlColumnEntity) => `ALTER TABLE ${mysqlIdent(column.table)} MODIFY COLUMN ${renderMysqlColumnDefinition(to)}`
// Strict mode refuses NOT NULL over existing NULLs; they take the default, as a new row
// would. The column takes its new type first, so the default is a value of it.
return op.fill && column.default !== null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

fill with no default writes MODIFY … NOT NULL that fails on existing NULLs

F3 · Warning · correctness

Making an existing nullable column NOT NULL with no default leaves op.fill true while column.default is null, so only MODIFY COLUMN … NOT NULL is written. Strict mode refuses it over any NULL row (the comment above says so), and MySQL DDL is not transactional, so the statements before it in migration.sql stay applied.

In `diffMysqlSchemas`, when `fill` is needed and `column.default === null`, push an `unsupported` entry instead of the op, so `generate` refuses the change rather than writing a statement that aborts on rows.
🤖 Prompt to fix with an AI agent
In `src/schema/mysql-ops.ts:75-81`: `fill` with no default writes `MODIFY … NOT NULL` that fails on existing NULLs.

Making an existing nullable column NOT NULL with no default leaves `op.fill` true while `column.default` is null, so only `MODIFY COLUMN … NOT NULL` is written. Strict mode refuses it over any NULL row (the comment above says so), and MySQL DDL is not transactional, so the statements before it in `migration.sql` stay applied.

Suggested fix: In `diffMysqlSchemas`, when `fill` is needed and `column.default === null`, push an `unsupported` entry instead of the op, so `generate` refuses the change rather than writing a statement that aborts on rows.

Verify the problem exists at that location before changing it, and keep the fix to those lines.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't fix. Making a column NOT NULL with no default is a valid migration whenever the column holds no NULLs: an empty table, or one that was backfilled first. Refusing it would block that common case, and nothing in the schema says whether NULLs exist. SQLite's rebuild behaves the same way (the copy fails on NULLs), and drizzle-kit writes the same statement. docs/migrations.md warns about this case in the MySQL section ("a NOT NULL with no default over NULLs" fails, and DDL is not transactional).

@Makisuo
Makisuo changed the base branch from feat/mysql-dialect to main November 6, 2026 11:11
@Makisuo
Makisuo merged commit 20cc602 into main Nov 6, 2026
4 of 5 checks passed
@Makisuo
Makisuo deleted the feat/mysql-schema branch November 6, 2026 11:11
@maple-review-bot

Copy link
Copy Markdown

Merged with 1 step from "Before merge" still open. If it is done, tick it on the review comment; if not, now is the time.

  • Manual · Run the live MySQL 8.4 and MariaDB 11.4 suites on the final commit (EFFECT_ORM_MYSQL_URL, EFFECT_ORM_MARIADB_URL)

@maple-review-bot

Copy link
Copy Markdown

Maple: after this shipped

⚠️ Something changed after this shipped. Details below.

Deployed to maple-web (pr-2302) at 2026-11-06 11:31 UTC as e5aaa0c, the first version after the merge (the merge commit itself never reported). Compared the 60 minutes before and after.

New errors since the deploy:

  • @maple/web/errors/WarehouseQueryError (maple-web, 9/h)

Errors in the changed files:

  • ➖ ToolCallFailed: 0/h → 0/h
  • ➖ @maple/http/errors/WarehouseAuthError: 0/h → 0/h
  • ➖ ContextBudgetError: 0/h → 0/h
  • ➖ ModelProtocolError: 0/h → 0/h
  • ➖ AgentPolicyError: 0/h → 0/h
  • ➖ @maple/security-audit/AgentError: 0/h → 0/h
  • ➖ Error: 0/h → 0/h
  • ➖ ContextBudgetError: 0/h → 0/h
  • ➖ ModelProtocolError: 0/h → 0/h

Maple looks once, an hour after the first deploy that carries the merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant